本文介绍 aplayer-react 库,以及我如何使用 aplayer-react 在我的 Gatsby 博客中播放网易云音乐歌曲。
最近在培养每周在我的博客更新周记的习惯。 在周记的开头,我会分享本周我在网易云音乐上新收藏的歌曲,有时是一首,有时也会有多首。 相比仅仅是将歌曲的链接贴在文中,我想干脆在周记中显示一个音乐播放器,将歌曲播放出来。
播放器的选型上我选择 @DIYgod 创建的 APlayer
库,因为我很喜欢它的外观。由于我的博客使用 React 编写,我编写了 aplayer-react
库,使得可以以 React 组件的形式使用 APlayer。
aplayer-react
aplayer-react
的实现上其实并未调用 APlayer 的 API。事实上,它只是使用了 APlayer 的样式表,功能逻辑则完全使用 React 重写。可以将其理解为“APlayer 原型的 React 实现”。
示例效果如下:
主要的特性包括:
- 滚动歌词
- 音量控制,可以切换静音
- 播放列表,可切换顺序播放/随机播放,以及单曲循环/列表循环
- 根据歌曲封面自动适配主题色
- 支持服务端渲染
基础用例
一个最基本的用例如下,为 APlayer 组件的 audio
属性传入一个包含歌曲信息的对象。
import { APlayer } from "aplayer-react"
import "aplayer/dist/APlayer.min.css"
render(
<APlayer
audio={{
name: "Dancing with my phone",
artist: "HYBS",
url: "https://music.163.com/song/media/outer/url?id=1969744125",
cover:
"https://p1.music.126.net/tOtUdKjS9rktAFRamcomWQ==/109951167748733958.jpg",
}}
/>
)
滚动歌词
在 audio
对象的 lrc
字段设置 LRC 格式的歌词,即可在界面上显示跟随歌曲进度滚动的歌词。
render(
<APlayer
audio={{
name: "Dancing with my phone",
artist: "HYBS",
url: "https://music.163.com/song/media/outer/url?id=1969744125",
cover:
"https://p1.music.126.net/tOtUdKjS9rktAFRamcomWQ==/109951167748733958.jpg",
lrc: "[00:00.000] 作词 : James Alyn Wee/Kasidej Hongladaromp\n[00:01.000] 作曲 : James Alyn Wee/Kasidej Hongladaromp\n[00:28.836] I'm just laying on the floor again\n[00:33.124] Can't be bothered to get up now\n[00:36.345] I wouldn’t care\n[00:38.348] If I never get up again\n[00:41.363] I don’t want to\n[00:47.388] Then our song comes on the radio\n[00:51.906] Makes me wanna start to dance, oh\n[00:55.163] I wanna know\n[00:56.997] If you feel the same way as me\n[01:00.097] Why would you go?\n[01:02.695]\n[01:05.780] Dancing, I'm all alone\n[01:09.163] Figuring out how I can get you home\n[01:15.129] Dancing with my phone\n[01:18.393] Thinking about you\n[01:22.292]\n[01:25.154] On my feet and now I'm out the door\n[01:29.741] Walking by the places that we used to go\n[01:34.478] I remember all your favorite stores\n[01:37.743] I won't lie\n[01:43.345] I don't think I even know myself anymore\n[01:52.741] You're the one who knew me ****ing well\n[01:58.914] Yeah, you know\n[02:00.129]\n[02:02.177] Dancing, I'm all alone\n[02:05.652] Figuring out how I can get you home\n[02:11.617] Dancing with my phone\n[02:14.687] Thinking about you\n[02:20.993] Dancing, I'm all alone (I'm dancing all alone)\n[02:24.218] Figuring out how I can get you home (How I can get you home)\n[02:30.291] Dancing with my phone (I'm dancing with my phone)\n[02:33.626] Thinking about you\n[02:37.376]\n[02:39.724] Dancing all alone, dancing all alone (I'm dancing all alone)\n[02:44.589] Dancing all alone, dancing all alone (I'm dancing with my phone)\n[02:49.284] Dancing with my phone\n[02:52.504] Thinking about you\n[02:58.522] Dancing all alone, dancing all alone\n[03:03.262] Dancing all alone, dancing all alone (Thinking about you)\n[03:08.094] Dancing with my phone\n[03:11.402] Thinking about you\n",
}}
/>
)
播放列表
audio
属性除了接收单个歌曲信息对象外,也可以接收包含多个歌曲信息的数组。当 audio
为数组时,会显示一个播放列表。
render(
<APlayer
audio={[
{
name: "Dancing with my phone",
artist: "HYBS",
url: "https://music.163.com/song/media/outer/url?id=1969744125",
cover:
"https://p1.music.126.net/tOtUdKjS9rktAFRamcomWQ==/109951167748733958.jpg",
lrc: "[00:00.000] 作词 : James Alyn Wee/Kasidej Hongladaromp\n[00:01.000] 作曲 : James Alyn Wee/Kasidej Hongladaromp\n[00:28.836] I'm just laying on the floor again\n[00:33.124] Can't be bothered to get up now\n[00:36.345] I wouldn’t care\n[00:38.348] If I never get up again\n[00:41.363] I don’t want to\n[00:47.388] Then our song comes on the radio\n[00:51.906] Makes me wanna start to dance, oh\n[00:55.163] I wanna know\n[00:56.997] If you feel the same way as me\n[01:00.097] Why would you go?\n[01:02.695]\n[01:05.780] Dancing, I'm all alone\n[01:09.163] Figuring out how I can get you home\n[01:15.129] Dancing with my phone\n[01:18.393] Thinking about you\n[01:22.292]\n[01:25.154] On my feet and now I'm out the door\n[01:29.741] Walking by the places that we used to go\n[01:34.478] I remember all your favorite stores\n[01:37.743] I won't lie\n[01:43.345] I don't think I even know myself anymore\n[01:52.741] You're the one who knew me ****ing well\n[01:58.914] Yeah, you know\n[02:00.129]\n[02:02.177] Dancing, I'm all alone\n[02:05.652] Figuring out how I can get you home\n[02:11.617] Dancing with my phone\n[02:14.687] Thinking about you\n[02:20.993] Dancing, I'm all alone (I'm dancing all alone)\n[02:24.218] Figuring out how I can get you home (How I can get you home)\n[02:30.291] Dancing with my phone (I'm dancing with my phone)\n[02:33.626] Thinking about you\n[02:37.376]\n[02:39.724] Dancing all alone, dancing all alone (I'm dancing all alone)\n[02:44.589] Dancing all alone, dancing all alone (I'm dancing with my phone)\n[02:49.284] Dancing with my phone\n[02:52.504] Thinking about you\n[02:58.522] Dancing all alone, dancing all alone\n[03:03.262] Dancing all alone, dancing all alone (Thinking about you)\n[03:08.094] Dancing with my phone\n[03:11.402] Thinking about you\n",
},
{
name: "僕は今日も",
artist: "Vaundy",
url: "https://music.163.com/song/media/outer/url?id=1441997419",
cover:
"https://p1.music.126.net/AnR2ejcBgGnOJXPsytivBQ==/109951164922366027.jpg",
lrc: "[00:00.000] 作词 : Vaundy\n[00:00.002] 作曲 : Vaundy\n[00:00.04]僕は今日も - Vaundy\n[00:15.00]母さんが言ってたんだ\n[00:22.31]お前は才能があるから\n[00:29.06]「芸術家にでもなりな」と\n[00:35.98]また根拠の無い夢を語る\n[00:49.77]父さんが言ってたんだ\n[00:56.58]お前は親不孝だから\n[01:03.45]1人で生きていきなさい\n[01:08.69]また意味もわからず罵倒する\n[01:16.24]1人ではないと暗示をして\n[01:19.89]2人ではないとそう聞こえて\n[01:23.13]思ってるだけじゃ\n[01:24.88]そう 辛くてでも\n[01:26.72]そうする他にすべはなくて\n[01:29.85]愉快な日々だと暗示をして\n[01:33.38]不協和音が 聞こえてきた\n[01:36.80]抑えてるだけじゃ そう 辛くて\n[01:40.08]だから この気持ちを弾き語るよ\n[01:43.83]もしも僕らが生まれてきて\n[01:50.68]もしも僕らが大人になっても\n[01:57.48]もしも僕らがいなくなっていても\n[02:04.37]そこに僕の歌があれば\n[02:09.83]それでいいさ\n[02:18.90]彼女が言ってたんだ\n[02:25.69]あなたはカッコイイから\n[02:32.60]イケメンじゃなくていいんだよ\n[02:37.87]また元も子も無い言葉を君は言う\n[02:45.55]僕はできる子と暗示をして\n[02:48.87]心が折れる音が聞こえた\n[02:52.25]思ってるだけじゃ\n[02:54.02]そう 辛くてでも\n[02:55.86]そうする他にすべはなくて\n[02:59.12]明日は晴れると暗示をして\n[03:02.50]次の日は傘を持って行った\n[03:06.00]抑えてるだけじゃ そう 辛くて\n[03:09.10]だから この気持ちを弾き語るよ\n[03:12.96]もしも僕らが生まれてきて\n[03:19.77]もしも僕らが大人になっても\n[03:26.59]もしも僕らがいなくなっていても\n[03:33.52]そこに僕の歌があれば\n[03:38.92]それでいいさ\n[03:41.08]ピアノの音が聞こえる\n[03:47.91]ガラガラの声が聞こえる\n[03:54.01]枯れてく僕らの音楽に\n[03:57.08]飴をやって もう少しと\n[04:01.57]その気持ちを弾き語るよ\n[04:07.75]もしも僕らが生まれてきて\n[04:14.66]もしも僕らが大人になっても\n[04:21.58]もしも僕らがいなくなっていても\n[04:28.40]そこに僕の歌があれば\n[04:33.72]それでいいさ\n[04:35.48]もしも僕らに才能がなくて\n[04:42.13]もしも僕らが親孝行して\n[04:49.00]もしも僕らがイケていたら\n[04:54.74]ずっとそんなことを思ってさ\n[05:01.31]弾き語るよ\n",
},
...
]}
/>
)
以上是几个常用功能的简介,完整的使用说明见 https://aplayer-react.js.org。
源码仓库见 https://github.com/SevenOutmanm/aplayer-react。
我如何使用 aplayer-react
我的博客使用 Gatsby 搭建,使用 Markdown 编写文章,并可以在 frontmatter 中存放一些信息,通过 GraphQL 查询。
在周记中,我将本周收藏的歌曲链接放到 frontmatter 中的 songs
字段。
例如:
---
title: 22w47
date: 2022-11-25
songs:
- https://music.163.com/song?id=1969744125
- https://music.163.com/song?id=1441997419
---
在文章页面组件中,使用 gatsby-transformer-remark
插件提供的 markdownRemark
GraphQL 查询可以读取出 frontmatter 中的数据
export const pageQuery = `graphql
query {
markdownRemark(id: { eq: $id }) {
frontmatter {
title
date(formatString: "MMMM DD, YYYY")
songs
}
}
}
`;
export default function Weekly({
data: { markdownRemark }
}) {
return (
<NeteaseMusicPlayer songUrls={markdownRemark.frontmatter.songs} />
...
)
}
这里的 NeteaseMusicPlayer
组件,是用于根据 songUrls
属性中的歌曲链接来获取歌曲的名称、歌手、封面图、歌词等信息。大致逻辑如下:
import { APlayer } from "aplayer-react"
export function NeteaseMusicPlayer({ songUrls }) {
// 从歌曲分享链接中提取歌曲 id
const songIds = useMemo(() => {
return songUrls.map(url => getSongId(url))
}, [songUrls])
// 根据歌曲 id 查询歌曲详细信息、歌词
const songInfos = useSongInfos(songIds)
return <APlayer audio={songInfos} theme="auto" autoPlay />
}
useSongInfos
钩子中通过请求 NeteaseCloudMusicApi 来查询歌曲的详细信息和歌词。
export function useSongInfos(songIds) {
// 初始返回歌曲的媒体播放地址,从而播放器可以先开始播放
const [songInfos, setSongInfos] = useState(() =>
songIds.map(id => composeMediaUrl(id))
)
useEffect(() => {
// 获取歌曲详细信息
fetch(`ncm.api/song/detail?ids=${songIds.join(",")}`)
.then(response => response.json())
.then(({ songs }) => {
setSongInfos(
songs.map(songInfo => {
return {
name: songInfo.name,
artist: songInfo.ar.map(artist => artist.name).join("/"),
url: `https://music.163.com/song/media/outer/url?id=${songInfo.id}`,
cover: songInfo.al.picUrl,
}
})
)
songs.forEach((songInfo, index) => {
// 获取歌曲歌词
fetch(`ncm.api/lyric?id=${songInfo.id}`)
.then(response => response.json())
.then(({ lrc: { lyric } }) => {
setSongInfos(prev => {
const song = prev[index]
return [
...prev.slice(0, index),
{
...song,
lrc: lyric,
},
...prev.slice(index + 1),
]
})
})
})
})
}, [songIds])
return songInfos
}
效果如下:
但是到目前为止,这样的实现存在一个小问题。在歌曲信息加载完成之前,播放器会短暂地显示缺省状态,观感上还是有些奇怪。
既然每篇周记中包含的歌曲,在周记创建的时候就已经确定了,能否提前将歌曲的详细信息获取完直接静态地写进页面呢?刚好 Gatsby 提供了相关的能力。
Gatsby 允许在页面中通过编写 GraphQL 的形式查询数据用于展示,并且这个查询发生在构建阶段,查询到的数据直接以静态数据的形式写进页面。于是我们可以通过 Gatsby 提供的创建自定义 GraphQL 的能力,创建一个能够读取网易云音乐详情的 GraphQL 查询。
在 gatsby-node.js
中,添加 createSchemaCustomization
方法,来添加自定义的 GraphQL 类型声明。
// gatsby-node.js
/**
* @type {import('gatsby').GatsbyNode['createSchemaCustomization']}
*/
exports.createSchemaCustomization = ({ actions: { createTypes } }) => {
createTypes(`
# Netease Cloud Music songs' info
type NeteaseCloudMusicSong {
id: Int
name: String
mediaUrl: String
ar: [NeteaseCloudMusicArtist]
al: NeteaseCloudMusicAlbum
lrc: String
}
type NeteaseCloudMusicArtist {
name: String
}
type NeteaseCloudMusicAlbum {
picUrl: String
}
type MarkdownRemark implements Node {
frontmatter: Frontmatter
}
type Frontmatter {
songs: [NeteaseCloudMusicSong]
}
`)
}
这里我根据我所需要的歌曲详情信息,创建了歌曲信息类型 NeteaseCloudMusicSong
。并扩展了 gatsby-transformer-remark
提供的 MarkdownRemark
类型,使得 frontmatter
中增加一个 songs
字段,来查询我们的 NeteaseCloudMusicSong
信息。
接着,添加 createResolvers
方法,来声明如何解析 songs
字段。
// gatsby-node.js
const ncmApi = require("NeteaseCloudMusicApi")
/**
* @type {import('gatsby').GatsbyNode['createResolvers']}
*/
exports.createResolvers = ({ createResolvers }) => {
const resolvers = {
Frontmatter: {
songs: {
type: ["NeteaseCloudMusicSong"],
resolve(source) {
if (!source.songs) return source.songs
// 从歌曲分享链接中提取歌曲 id
const songIds = source.songs.map(url => getSongId(url))
// 根据歌曲 id 查询歌曲详细信息
return ncmApi
.song_detail({ ids: songIds.join(",") })
.then(response => response.body.songs)
},
},
},
NeteaseCloudMusicSong: {
mediaUrl: {
type: "String",
resolve(source) {
return composeMediaUrl(source.id)
},
},
lrc: {
type: "String",
resolve(source) {
return ncmApi
.lyric({ id: source.id })
.then(response => response.body.lrc.lyric)
},
},
},
}
createResolvers(resolvers)
}
接着,在文章页面组件中,修改 GraphQL 查询,直接读取 songs 的各个详情字段。
export const pageQuery = `graphql
query {
markdownRemark(id: { eq: $id }) {
frontmatter {
title
date(formatString: "MMMM DD, YYYY")
songs {
name
ar {
name
}
mediaUrl
al {
picUrl
}
lrc
}
}
}
}
`;
export default function Weekly({
data: { markdownRemark }
}) {
return (
<NeteaseMusicPlayer songs={markdownRemark.frontmatter.songs} />
...
)
}
最后,从 NeteaseMusicPlayer
组件移除请求 API 的逻辑,仅仅将 GraphQL 查询结果的结构转为 aplayer-react
接收的结构即可。
export function NeteaseMusicPlayer({ songs }) {
return (
<APlayer
audio={songs.map(songInfo => ({
name: songInfo.name,
artist: songInfo.ar.map(artist => artist.name).join("/"),
url: songInfo.mediaUrl,
cover: songInfo.al.picUrl,
lrc: songInfo.lrc,
}))}
theme="auto"
autoPlay
/>
)
}
这样一来,首次加载时播放器就已经具有完整的歌曲详细信息,不会再显示缺省状态。
结语
最后,欢迎大家 star 收藏 aplayer-react GitHub 仓库 ,也欢迎来我的博客留言。
祝过年好。